web/src/routes/image/[id]/+page.svelte (view raw)
1<script lang="ts">
2 import { onMount } from 'svelte';
3 import { getImage, addTags, removeTags, suggestTags, imageDownloadUrl, type ImageDetail } from '$lib/api';
4
5 let { params } = $props();
6 let id = $derived(parseInt(params.id));
7
8 let img = $state<ImageDetail | null>(null);
9 let loading = $state(true);
10 let error = $state('');
11
12 let tagInput = $state('');
13 let suggestions = $state<string[]>([]);
14 let showSuggestions = $state(false);
15 let tagError = $state('');
16
17 onMount(async () => {
18 try {
19 img = await getImage(id);
20 } catch (err) {
21 error = err instanceof Error ? err.message : 'Failed to load image';
22 } finally {
23 loading = false;
24 }
25 });
26
27 function formatSize(bytes: number): string {
28 if (bytes < 1024) return bytes + ' B';
29 if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
30 return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
31 }
32
33 function formatDate(s: string): string {
34 return new Date(s).toLocaleString();
35 }
36
37 async function onTagInput() {
38 const q = tagInput.split(',').pop()?.trim() || '';
39 if (q.length < 1) {
40 suggestions = [];
41 showSuggestions = false;
42 return;
43 }
44 try {
45 const result = await suggestTags(q);
46 suggestions = result.map((t) => t.tag);
47 showSuggestions = suggestions.length > 0;
48 } catch {
49 suggestions = [];
50 showSuggestions = false;
51 }
52 }
53
54 function selectSuggestion(s: string) {
55 const parts = tagInput.split(',').slice(0, -1);
56 parts.push(s);
57 tagInput = parts.join(', ') + ', ';
58 showSuggestions = false;
59 }
60
61 async function handleAddTags() {
62 if (!img) return;
63 const tags = tagInput.split(',').map((t) => t.trim()).filter(Boolean);
64 if (tags.length === 0) return;
65 tagError = '';
66 try {
67 const result = await addTags(img.id, tags);
68 img.tags_detail = result.tags_detail;
69 img.tags = result.tags_detail.map((t) => t.tag);
70 tagInput = '';
71 } catch (err) {
72 tagError = err instanceof Error ? err.message : 'Failed to add tags';
73 }
74 }
75
76 async function handleRemoveTags() {
77 if (!img) return;
78 const tags = tagInput.split(',').map((t) => t.trim()).filter(Boolean);
79 if (tags.length === 0) return;
80 tagError = '';
81 try {
82 const result = await removeTags(img.id, tags);
83 img.tags_detail = result.tags_detail;
84 img.tags = result.tags_detail.map((t) => t.tag);
85 tagInput = '';
86 } catch (err) {
87 tagError = err instanceof Error ? err.message : 'Failed to remove tags';
88 }
89 }
90</script>
91
92{#if loading}
93 <p class="state-msg">Loading...</p>
94{:else if error}
95 <p class="state-msg error">{error}</p>
96{:else if img}
97 <div class="page-head">
98 <h1>{img.filename}</h1>
99 <div class="head-line"></div>
100 </div>
101
102 <div class="layout">
103 <div class="preview">
104 {#if img.thumbnail_path}
105 <img src={imageDownloadUrl(img.id)} alt={img.filename} />
106 {:else}
107 <div class="no-thumb">NO THUMBNAIL</div>
108 {/if}
109 </div>
110
111 <div class="meta">
112 <div class="meta-block uploader-block">
113 <span class="meta-label">UPLOADED BY</span>
114 <div class="uploader-row">
115 {#if img.avatar_url}
116 <img src={img.avatar_url} alt="" class="avatar-img" />
117 {/if}
118 <a href="/browse?uploader={encodeURIComponent(img.uploader_username)}" class="meta-value uploader-link">{img.uploader_username}</a>
119 </div>
120 </div>
121 <div class="meta-block">
122 <span class="meta-label">ID</span>
123 <span class="meta-value">#{img.id}</span>
124 </div>
125 <div class="meta-block">
126 <span class="meta-label">DIMENSIONS</span>
127 <span class="meta-value">{img.width} × {img.height}</span>
128 </div>
129 <div class="meta-block">
130 <span class="meta-label">FILE SIZE</span>
131 <span class="meta-value">{formatSize(img.file_size)}</span>
132 </div>
133 <div class="meta-block">
134 <span class="meta-label">TYPE</span>
135 <span class="meta-value">{img.mime_type}</span>
136 </div>
137 <div class="meta-block">
138 <span class="meta-label">UPLOADED</span>
139 <span class="meta-value">{formatDate(img.created_at)}</span>
140 </div>
141 <div class="meta-block tags-block">
142 <span class="meta-label">TAGS</span>
143 <div class="tag-pills">
144 {#if img.tags_detail.length > 0}
145 {#each img.tags_detail as t (t.tag)}
146 <a href="/browse?tag={encodeURIComponent(t.tag)}" class="tag-pill" title="Used {t.usage_count} time{t.usage_count === 1 ? '' : 's'}">
147 <span class="tag-name">{t.tag}</span>
148 <span class="tag-count">{t.usage_count}</span>
149 </a>
150 {/each}
151 {:else}
152 <span class="none">none</span>
153 {/if}
154 </div>
155 </div>
156
157 {#if img.is_owner}
158 <div class="meta-block edit-tags-block">
159 <span class="meta-label">EDIT TAGS</span>
160 <div class="tag-input-row">
161 <div class="tag-wrapper">
162 <input type="text" placeholder="funny, meme, cat" bind:value={tagInput}
163 oninput={onTagInput}
164 onblur={() => setTimeout(() => { showSuggestions = false; }, 200)}
165 />
166 {#if showSuggestions}
167 <ul class="suggestions">
168 {#each suggestions as s}
169 <li role="button" tabindex="0" onmousedown={() => selectSuggestion(s)}>{s}</li>
170 {/each}
171 </ul>
172 {/if}
173 </div>
174 <button class="tag-btn add" onclick={handleAddTags}>ADD</button>
175 <button class="tag-btn remove" onclick={handleRemoveTags}>REMOVE</button>
176 </div>
177 {#if tagError}
178 <span class="tag-err">{tagError}</span>
179 {/if}
180 </div>
181 {/if}
182
183 <a href={imageDownloadUrl(img.id)} class="btn" download={img.filename}>
184 DOWNLOAD ORIGINAL
185 </a>
186 </div>
187 </div>
188
189 <p class="back"><a href="/browse">← BACK TO BROWSE</a></p>
190{/if}
191
192<style>
193 .state-msg {
194 padding: 2rem 0;
195 font-size: 1.1rem;
196 color: #888;
197 }
198 .error {
199 color: #c62828;
200 }
201
202 .page-head {
203 margin-bottom: 1.5rem;
204 }
205 .page-head h1 {
206 word-break: break-all;
207 }
208 .head-line {
209 height: 3px;
210 background: #c62828;
211 width: 3rem;
212 margin-top: 0.4rem;
213 }
214
215 .layout {
216 display: flex;
217 gap: 2rem;
218 flex-wrap: wrap;
219 }
220 .preview {
221 flex: 1;
222 min-width: 300px;
223 background: #1a1a1a;
224 display: flex;
225 align-items: center;
226 justify-content: center;
227 }
228 .preview img {
229 display: block;
230 width: 100%;
231 height: auto;
232 max-height: 70vh;
233 object-fit: contain;
234 }
235 .no-thumb {
236 padding: 4rem 2rem;
237 color: #555;
238 font-family: 'Oswald', sans-serif;
239 font-weight: 500;
240 font-size: 0.9rem;
241 letter-spacing: 0.12em;
242 }
243
244 .meta {
245 width: 260px;
246 flex-shrink: 0;
247 display: flex;
248 flex-direction: column;
249 gap: 1rem;
250 }
251 .meta-block {
252 display: flex;
253 flex-direction: column;
254 gap: 0.15rem;
255 }
256 .meta-label {
257 font-family: 'Oswald', sans-serif;
258 font-weight: 500;
259 font-size: 0.7rem;
260 letter-spacing: 0.15em;
261 color: #999;
262 }
263 .meta-value {
264 font-family: 'Inter', sans-serif;
265 font-size: 0.9rem;
266 font-weight: 600;
267 color: #1a1a1a;
268 }
269
270 .uploader-row {
271 display: flex;
272 align-items: center;
273 gap: 0.4rem;
274 }
275 .uploader-link {
276 color: #1a1a1a;
277 text-decoration: none;
278 }
279 .uploader-link:hover {
280 color: #c62828;
281 text-decoration: underline;
282 }
283
284 .avatar-img {
285 width: 1.5rem;
286 height: 1.5rem;
287 object-fit: cover;
288 flex-shrink: 0;
289 }
290
291 .tags-block {
292 flex: 1;
293 }
294 .tag-pills {
295 display: flex;
296 flex-wrap: wrap;
297 gap: 0.35rem;
298 }
299 .tag-pill {
300 display: inline-flex;
301 align-items: center;
302 gap: 0.3rem;
303 background: #1a1a1a;
304 padding: 0.25rem 0.5rem 0.25rem 0.65rem;
305 font-family: 'Inter', sans-serif;
306 font-size: 0.78rem;
307 font-weight: 500;
308 color: #eee;
309 text-decoration: none;
310 transition: background 0.15s;
311 }
312 .tag-pill:hover {
313 background: #c62828;
314 text-decoration: none;
315 }
316 .tag-name {
317 font-weight: 600;
318 }
319 .tag-count {
320 background: rgba(255, 255, 255, 0.12);
321 padding: 0.05rem 0.35rem;
322 font-size: 0.68rem;
323 font-weight: 600;
324 color: rgba(255, 255, 255, 0.65);
325 min-width: 1.1rem;
326 text-align: center;
327 }
328 .tag-pill:hover .tag-count {
329 background: rgba(0, 0, 0, 0.2);
330 }
331 .none {
332 color: #bbb;
333 font-size: 0.85rem;
334 }
335
336 .btn {
337 display: inline-flex;
338 align-items: center;
339 align-self: flex-start;
340 padding: 0.55rem 1.25rem;
341 background: #c62828;
342 color: #fff;
343 border: none;
344 font-family: 'Oswald', sans-serif;
345 font-weight: 500;
346 font-size: 0.8rem;
347 letter-spacing: 0.1em;
348 text-decoration: none;
349 text-transform: uppercase;
350 cursor: pointer;
351 transition: background 0.15s;
352 margin-top: 0.5rem;
353 }
354 .btn:hover {
355 background: #b71c1c;
356 text-decoration: none;
357 }
358
359 .back {
360 margin-top: 2rem;
361 }
362 .back a {
363 font-family: 'Oswald', sans-serif;
364 font-weight: 500;
365 font-size: 0.85rem;
366 letter-spacing: 0.1em;
367 }
368
369 .edit-tags-block {
370 border-top: 2px solid #1a1a1a;
371 padding-top: 0.75rem;
372 }
373 .tag-input-row {
374 display: flex;
375 gap: 0.35rem;
376 align-items: flex-start;
377 }
378 .tag-wrapper {
379 flex: 1;
380 position: relative;
381 min-width: 0;
382 }
383 .tag-wrapper input[type="text"] {
384 width: 100%;
385 padding: 0.4rem 0.5rem;
386 border: 2px solid #1a1a1a;
387 border-radius: 0;
388 font-family: 'Inter', sans-serif;
389 font-size: 0.78rem;
390 font-weight: 500;
391 background: #fff;
392 box-sizing: border-box;
393 }
394 .tag-wrapper input[type="text"]:focus {
395 outline: none;
396 border-color: #c62828;
397 }
398 .suggestions {
399 position: absolute;
400 top: 100%;
401 left: 0;
402 right: 0;
403 background: #fff;
404 border: 2px solid #1a1a1a;
405 border-top: none;
406 list-style: none;
407 margin: 0;
408 padding: 0;
409 z-index: 10;
410 max-height: 130px;
411 overflow-y: auto;
412 }
413 .suggestions li {
414 padding: 0.35rem 0.5rem;
415 cursor: pointer;
416 font-family: 'Inter', sans-serif;
417 font-size: 0.78rem;
418 font-weight: 500;
419 }
420 .suggestions li:hover {
421 background: #f0f0f0;
422 }
423 .tag-btn {
424 padding: 0.4rem 0.65rem;
425 border: none;
426 font-family: 'Oswald', sans-serif;
427 font-weight: 500;
428 font-size: 0.72rem;
429 letter-spacing: 0.08em;
430 cursor: pointer;
431 white-space: nowrap;
432 transition: background 0.15s;
433 }
434 .tag-btn.add {
435 background: #1a1a1a;
436 color: #fff;
437 }
438 .tag-btn.add:hover {
439 background: #2e7d32;
440 }
441 .tag-btn.remove {
442 background: #1a1a1a;
443 color: #fff;
444 }
445 .tag-btn.remove:hover {
446 background: #c62828;
447 }
448 .tag-err {
449 display: block;
450 margin-top: 0.3rem;
451 color: #c62828;
452 font-family: 'Inter', sans-serif;
453 font-size: 0.72rem;
454 font-weight: 600;
455 }
456</style>